Skip to main content

ES6 Modules

[[02. Factory Functions and the Module Pattern#III. The Module Pattern Immediately Invoked Function Expression (IIFEs) |The module pattern]] play a big part in helping to manage complex projects, file organization, the release of ES6 (ES2015).

ES6 modules provide a clean, explicit way to organize JavaScript code across multiple files. They solve the global scope pollution problem that existed before ES6, while providing clear dependency management and better code organization.

I. Pre ES6

1. The global scope problem

Before ES6 modules, JavaScript files shared the same global scope when loaded into HTML. This created significant difficulties for code organization and variable safety.

Example:
  • We have two scripts, one.js and two.js, we link them in our HTML as separate scripts.
<script src="one.js" defer></script>
<script src="two.js" defer></script>
// one.js
const greeting = "Hello, Odinite!";

// two.js
console.log(greeting);

→ When we open the HTML, we see "Hello, Odinite!" getting logged to the console, even though greeting was never defined in two.js

This is very problematic because:

  • All variables became global, causing naming conflicts. There’s no encapsulation or privacy.
  • No control over what gets exposed between files.
  • Order of the script loading mattered significantly. (if we had linked script two.js before one.js, the console.log(greeting) would have caused an error).

2. The Module Pattern with IIFEs solution

Before ES6, developers used Immediately Invoked Function Expressions (IIFEs) to create private scope:

// one.js - Creating private scope
(() => {
const greeting = "Hello, Odinite!"; // Now private!
})();

// two.js
console.log(greeting); // Error: greeting is not defined

If we want some things to be exposed to other files, we can return those things from our IIFE into the global scope while keeping other things private:

// one.js - Exposing only what we want
const greeting = (() => {
const greetingString = "Hello, Odinite!"; // Will be exposed
const farewellString = "Bye bye, Odinite!"; // Stays private

return greetingString; // Only this gets exposed globally
})();

// two.js
console.log(greeting); // Works: "Hello, Odinite!"
console.log(farewellString); // Error: not accessible

II. ES6 Module Solution

ES6 modules provide true module scope, where each file has its own private scope by default. Communication between files happens through explicit import and export statements.

  • Each module has private scope (not global)
  • Explicit control over what gets exported
  • Explicit control over what gets imported
  • Dependencies are clearly declared
// In an ES6 module
const myVariable = "I'm private to this module";
// This variable is NOT available globally

// In the browser console:
console.log(myVariable); // ReferenceError: myVariable is not defined

File paths and imports are case-sensitive.

1. Export Syntax

export

a. Named Exports

Named exports allow you to export multiple items from a single module, each with a specific name.

  • Inline export:
// math.js
export const PI = 3.14159;
export const E = 2.71828;

export function add(a, b) {
return a + b;
}

export function multiply(a, b) {
return a * b;
}
  • End-of-file export:
// math.js
const PI = 3.14159;
const E = 2.71828;

function add(a, b) {
return a + b;
}

function multiply(a, b) {
return a * b;
}

// Export at the end
export { PI, E, add, multiply };

b. Default Exports

A module can have only one default export. The imported name can be chosen by the importer.

  • Inline export:
// calculator.js
export default function calculator(operation, a, b) {
switch(operation) {
case 'add': return a + b;
case 'subtract': return a - b;
default: throw new Error('Unknown operation');
}
}
  • End-of-file export:
// calculator.js
function calculator(operation, a, b) {
switch(operation) {
case 'add': return a + b;
case 'subtract': return a - b;
default: throw new Error('Unknown operation');
}
}

export default calculator;

c. Default vs. Named Exports

We use default exports when:

  • Exporting a single main function/class
  • The module has one primary purpose

We use named exports when:

  • Exporting multiple related utilities
  • Want to be explicit about names
  • Building a library with multiple functions

2. Import Syntax

import

a. Named Imports

To import specific named export, we use curly braces. The curly braces in imports are NOT object destructuring - they're special ES6 module syntax.

// main.js
import { PI, add, multiply } from "./math.js";

console.log(PI); // 3.14159
console.log(add(5, 3)); // 8
console.log(multiply(4, 7)); // 28

b. Default Imports

Import the default export without curly braces, we can decide on any name we want.

// main.js
import calc from "./calculator.js";
// Could also be: import myCalculator from "./calculator.js";

console.log(calc('add', 10, 5)); // 15

c. Namespace Imports

Import everything under a namespace.

// Good: Clear, single responsibility
// math.js - mathematical utilities
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

// user.js - user-related functionality
export default class User {
constructor(name) { this.name = name; }
}
export function validateUser(user) { /* validation logic */ }
// namespace import
import * as math from './math.js';
math.add(5, 3);
math.PI;

d. Import Aliases

Rename imports to avoid conflicts.

import { add as mathAdd } from './math.js';
import { add as stringAdd } from './strings.js';

III. Entry Points and Dependency Management

When we use ESM, instead of adding every JavaScript file to our HTML in order, we only need to link a single file—the entry point.

If two.js imports variables from one.js and one.js imports variables from three.js, two.js depends on both one.js and three.js.

two.js <-------------- one.js <-------------- three.js

When we load two.js as module, the browser can see that it depends on one.js and three.js and load the code from these files as well.

<script src="two.js" type="module"></script>

→ we only need one script tag and the browser will handle additional file dependencies for us. We also do not need to add the defer attribute, as type="module will automatically defer script execution for us.

Example: The browser automatically:

  1. Loads main.js
  2. Sees it depends on utils.js, loads that
  3. Sees utils.js depends on math.js, loads that
  4. Executes in the correct order
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

// utils.js
import { add } from "./math.js";
export function addAndLog(a, b) {
const result = add(a, b);
console.log(`${a} + ${b} = ${result}`);
return result;
}

// main.js (entry point)
import { addAndLog } from "./utils.js";
addAndLog(5, 3); // Logs: "5 + 3 = 8"
<!-- Link only the entry point --> 
<script src="main.js" type="module"></script>

III. CommonJS vs. ES6 Modules

1. CommonJS (Node.js traditional)

A module system that was designed for use with Node.js that works a little differently than ESM, and is not something that browsers will be able to understand.

  • It uses syntax like require and module.export instead of just import and export.
// Exporting
module.exports = { add, subtract };
// or
exports.add = add;

// Importing
const { add, subtract } = require('./math');

2. ES6 Modules (Modern standard)

  • Asynchronous loading, designed for browsers
  • Static analysis possible (better tree-shaking)
  • More explicit dependency declaration
// Exporting
export { add, subtract };

// Importing
import { add, subtract } from './math.js';